/* eslint-disable @typescript-eslint/no-explicit-any */

import { type CSSStyle, cls, setCSS } from '@knno/jsx';
import SVGDown from '@mdi/svg/svg/arrow-down-box.svg';
import SVGRight from '@mdi/svg/svg/arrow-right-box.svg';
import SVGUp from '@mdi/svg/svg/arrow-up-box.svg';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { size2px } from '../../tools/utils';
import { defineElement } from '../defineElement';
import { Empty } from '../empty/empty';
import {
	GridBase,
	type GridBaseData,
	type GridBaseProps,
	type GridRowDragDetail,
	genWidth,
	type MeasureType,
	type RowType,
	setContent,
} from '../gridBase';
import {
	CACHE_TIME,
	DEFAULT_CELL_PADDING_PX,
	DEFAULT_LINE_HEIGHT_PX,
	GRID_CELL_BORDER_BOTTOM,
	GRID_CELL_PADDING,
	GRID_LINE_HEIGHT,
} from '../gridBaseConfig';
import { Icon } from '../icon/icon';
import { DATA } from '../symbol';
import {
	gridCellPaddingVar,
	gridDragIndicatorStyle,
	gridLineHeightVar,
	gridStyle,
} from './grid.css';

type DragRowConfig = {
	allowDragOver?: boolean;
	allowDragBefore?: boolean;
	allowDragAfter?: boolean;
};

type DragOptions = {
	onCompareRow?: (row1: any, row2: any) => boolean;
	onRowConfig?: (detail: GridRowDragDetail) => DragRowConfig;
	onRowLongHover?: (detail: GridRowDragDetail) => void;
	onDragEnd?: (
		dragRow: GridRowDragDetail,
		target: GridRowDragDetail | undefined,
		position: 'before' | 'after' | 'over' | undefined,
	) => void;
};

type DragContext<G extends Grid<any, any>> = {
	grid: G;
	body: HTMLDivElement;
	head: HTMLDivElement;
	mousePos: { x: number; y: number };
	dragRow: GridRowDragDetail;
	over: boolean;
	indicator?: HTMLDivElement;
	dragOptions: DragOptions;
	targetRowIdx?: number;
	targetPos?: 'before' | 'after' | 'over';
	rowConfig?: DragRowConfig;
	rowEnterTime?: number;
};

function renderDrag<G extends Grid<any, any>>(context: DragContext<G>) {
	if (context.over) {
		context.indicator?.remove();
		const dragRowIndex = context.grid.getRowIndex(
			context.dragRow.row,
			context.dragOptions.onCompareRow,
		);
		const targetRow: GridRowDragDetail | undefined =
			context.targetRowIdx !== undefined
				? {
						row: context.grid.getRow(context.targetRowIdx),
						rowIndex: context.targetRowIdx,
					}
				: undefined;
		context.dragOptions.onDragEnd?.(
			{
				row: context.dragRow.row,
				rowIndex: dragRowIndex,
			},
			targetRow,
			context.targetRowIdx !== undefined ? context.targetPos : undefined,
		);
		return;
	}
	if (!context.indicator) {
		context.indicator = (<div class={gridDragIndicatorStyle}></div>) as HTMLDivElement;
		document.body.appendChild(context.indicator);
	}
	const indi = context.indicator;
	const pt = context.mousePos;
	const boxRect = context.grid.el.getBoundingClientRect();
	const boxTop = boxRect.top + context.head.offsetHeight;
	const boxBottom = boxRect.bottom;
	const targetElement =
		pt.y >= boxRect.top && pt.y <= boxRect.bottom
			? document.elementsFromPoint(pt.x, pt.y).find((elem) => {
					return elem.parentElement === context.body && typeof (elem as any).lineNo === 'number';
				})
			: undefined;
	let valid = false;
	if (targetElement) {
		const targetRowIdx = (targetElement as any).lineNo as number;
		const row = context.grid.getRow(targetRowIdx);
		const same =
			typeof context.dragOptions.onCompareRow === 'function'
				? context.dragOptions.onCompareRow(row, context.dragRow.row)
				: row === context.dragRow.row;
		if (!same) {
			const targetDetail: GridRowDragDetail = {
				rowIndex: targetRowIdx,
				row: row,
			};
			if (context.targetRowIdx !== targetRowIdx) {
				context.targetRowIdx = targetRowIdx;
				context.rowConfig = context.dragOptions.onRowConfig?.(targetDetail);
			}
			function setStyle(top: number, height: string, over: boolean) {
				indi.style.top = `${top - 1}px`;
				indi.style.height = height;
				indi.classList.toggle('over', over);
			}
			const rect = targetElement.getBoundingClientRect();
			if (context.rowConfig?.allowDragOver) {
				if (
					pt.y <= rect.top + rect.height / 3 &&
					rect.top >= boxTop &&
					(context.rowConfig.allowDragBefore ?? true)
				) {
					setStyle(rect.top, '', false);
					valid = true;
					context.targetPos = 'before';
					context.rowEnterTime = undefined;
					context.indicator.replaceChildren(<Icon svg={SVGUp} />);
				} else if (
					pt.y >= rect.top + (rect.height / 3) * 2 &&
					rect.bottom <= boxBottom &&
					(context.rowConfig.allowDragAfter ?? true)
				) {
					setStyle(rect.top + rect.height, '', false);
					valid = true;
					context.targetPos = 'after';
					context.rowEnterTime = undefined;
					context.indicator.replaceChildren(<Icon svg={SVGDown} />);
				} else if (rect.top >= boxTop && rect.bottom <= boxBottom) {
					setStyle(rect.top, `${rect.height}px`, true);
					valid = true;
					context.targetPos = 'over';
					context.indicator.replaceChildren(<Icon svg={SVGRight} />);
					if (context.rowEnterTime !== undefined) {
						if (performance.now() - context.rowEnterTime >= 1000) {
							context.rowEnterTime = undefined;
							context.dragOptions.onRowLongHover?.(targetDetail);
						}
					} else {
						context.rowEnterTime = performance.now();
					}
				}
			} else {
				if (
					pt.y <= rect.top + rect.height / 2 &&
					rect.top >= boxTop &&
					(context.rowConfig?.allowDragBefore ?? true)
				) {
					setStyle(rect.top, '', false);
					valid = true;
					context.targetPos = 'before';
					context.indicator.replaceChildren(<Icon svg={SVGUp} />);
				} else if (rect.bottom <= boxBottom && (context.rowConfig?.allowDragAfter ?? true)) {
					setStyle(rect.top + rect.height, '', false);
					valid = true;
					context.targetPos = 'after';
					context.indicator.replaceChildren(<Icon svg={SVGDown} />);
				}
				context.rowEnterTime = undefined;
			}
			if (valid) {
				indi.style.left = `${rect.left}px`;
				indi.style.width = `${rect.width}px`;
			}
		}
	}
	if (valid) {
		indi.style.display = 'flex';
	} else {
		context.targetRowIdx = undefined;
		context.rowConfig = undefined;
		context.rowEnterTime = undefined;
		context.targetPos = undefined;
		indi.style.display = 'none';
	}
	requestAnimationFrame(() => {
		renderDrag(context);
	});
}

const GridElement = defineElement('knno-grid', HTMLDivElement, 'div');

export type GridConfig = {
	borderBottom: number;
	lineHeight: string;
	cellPadding: string;
};

export type GridData = GridBaseData & {
	head: HTMLDivElement;
	anchor: HTMLDivElement;
	body: HTMLDivElement;
	/**
	 * CSS for each column(generated)
	 */
	css: CSSStyle[];
	lh: number[]; // extHeight
	lt: number[]; // lineTop
	first: number | null; // firstLine
	lineHeightCache: { value: number; timeout: number } | null;
	config: GridConfig;
};

export type GridProps<T extends Grid<any, any>> = GridBaseProps<T> & {
	onScrollBottom?: (this: T) => void;
	autoHeight?: boolean;
	config?: Partial<GridConfig>;
};

export class Grid<
	P extends GridProps<C> = GridProps<Grid<any, any>>,
	C extends Grid<any, any> = Grid<any, any>,
> extends GridBase<P, C> {
	protected get data() {
		return this[DATA] as GridData;
	}

	constructor(props: P) {
		super(props);
		this.data.css = [];
		this.data.lh = [];
		this.data.lt = [];
		this.data.first = null;
		this.data.lineHeightCache = null;
		this.data.config = {
			borderBottom: GRID_CELL_BORDER_BOTTOM,
			lineHeight: GRID_LINE_HEIGHT,
			cellPadding: GRID_CELL_PADDING,
		};
		const root = this.el;
		// initialize props.
		this.init();
		if (props.config) this.setConfig(props.config);
		this.setAutoHeight(this.props.autoHeight ?? false);
		let scrollTop = this.el.scrollTop;
		this.el.addEventListener('scroll', () => {
			const newTop = this.el.scrollTop;
			if (scrollTop !== newTop) {
				scrollTop = newTop;
				this.renderBody();
			}
		});
		const resizeObserver = new ResizeObserver(() => {
			this.refresh();
		});
		resizeObserver.observe(root);
		const insObserver = new IntersectionObserver(
			(entries) => {
				if (entries[0].isIntersecting) {
					this.props.onScrollBottom?.call(this as unknown as C);
				}
			},
			{ root: root },
		);
		insObserver.observe(this.data.anchor);
	}

	protected render(): Node {
		return (
			<GridElement class={cls(this.props.class, gridStyle)} style={this.props.style}>
				<div class="head" ref={(d) => (this.data.head = d)} />
				<div class="body" ref={(d) => (this.data.body = d)} />
				<div class="anchor" ref={(d) => (this.data.anchor = d)} />
			</GridElement>
		);
	}

	setConfig(options: Partial<GridConfig>): this {
		if (options.lineHeight !== undefined) {
			this.data.config.lineHeight = options.lineHeight;
			setCSS(this.el, assignInlineVars({ [gridLineHeightVar]: options.lineHeight }));
		}
		if (options.cellPadding !== undefined) {
			this.data.config.cellPadding = options.cellPadding;
			setCSS(this.el, assignInlineVars({ [gridCellPaddingVar]: options.cellPadding }));
		}
		if (options.borderBottom !== undefined) {
			this.data.config.borderBottom = options.borderBottom;
		}
		this.refresh();
		return this;
	}

	protected override setActiveRow(rowIdx: number | null, focus: boolean): void {
		const re = this.el;
		Array.from(re.querySelectorAll(':scope>div.body>div.line')).forEach((l) =>
			l.classList.remove('active'),
		);
		this[DATA].actived = rowIdx;
		if (rowIdx !== null) {
			const tr = re.querySelector<HTMLDivElement>(`:scope>div.body>div.line.r${rowIdx}`);
			tr?.classList.add('active');
			if (focus) {
				const offset = this.data.body.offsetTop;
				const headerHeight = this.headLine ? this.data.head.offsetHeight : 0;
				if (tr) {
					if (tr.offsetTop + offset + tr.offsetHeight > re.scrollTop + re.clientHeight) {
						// after the viewport.
						re.scrollTop = tr.offsetTop + offset + tr.offsetHeight - re.clientHeight;
					}
					if (tr.offsetTop + offset < re.scrollTop + headerHeight) {
						// before the viewport.
						re.scrollTop = tr.offsetTop + offset - headerHeight;
					}
				} else {
					const lineHeight = this.getLineHeight();
					const top = this.getLineOffset(headerHeight, lineHeight, rowIdx);
					if (top + lineHeight > re.scrollTop + re.clientHeight) {
						// after the viewport.
						re.scrollTop = top + lineHeight - re.clientHeight;
					}
					if (top < re.scrollTop + headerHeight) {
						// before the viewport.
						re.scrollTop = top - headerHeight;
					}
				}
			}
		}
	}

	protected override renderSelect() {
		const columns = this.getColumns();
		const th = this.el.querySelector(`:scope>div.head>div.c0`);
		if (th) {
			setContent(th, columns[0].formatter?.({}, null, -1, 0) ?? []);
		}
		const tds = this.el.querySelectorAll(`:scope>div.body>div.line>div.c0`);
		tds.forEach((td) => {
			setContent(
				td,
				columns[0].formatter?.([], null, (td.parentElement as any).lineNo as number, 0) ?? [],
			);
		});
	}

	protected override genStyle() {
		if (!this.el.isConnected) return false;
		const columns = this.adjustColumnSize();
		const autoColumnSize = this.columnSize === 'auto';
		this.data.css = [];
		columns.forEach((col, idx) => {
			const width = genWidth(col.width);
			const cells = Array.from(this.el.querySelectorAll<HTMLDivElement>(`div.head>div.c${idx}`));
			const css: CSSStyle = { width, position: undefined, left: '', zIndex: '' };
			if (col.fixed) {
				css.flexShrink = '0';
				css.flexGrow = '0';
			} else if (autoColumnSize) {
				css.flexShrink = '0';
				css.flexGrow = size2px(width, this.el, false) + '';
			}
			cells.forEach((c) => setCSS(c, css));
			if (idx < this.fixColumns) {
				css.position = 'sticky';
				css.left = `${(this.data.head.children[idx] as HTMLElement).offsetLeft}px`;
				css.zIndex = '1';
				cells.forEach((c) => setCSS(c, css));
			}
			this.data.css.push(css);
		});
		return true;
	}

	protected measure(reason: MeasureType): void {
		if (reason !== 'selection') {
			if (reason === 'current-page') {
				this.data.first = null;
			} else {
				this.data.lh = [];
				this.data.lt = [];
				this.data.first = null;
				this.data.body.style.top = '0px';
			}
			this.data.anchor.style.top = '0px';
		}
	}

	protected override renderHead() {
		if (!this.el.isConnected) return false;
		const columns = this.getColumns();
		this.data.head.innerHTML = '';
		const headLine = this.headLine;
		this.data.head.append(
			...columns.map((col, colIdx) =>
				headLine ? this.renderHeadCell(document.createElement('div'), col, colIdx) : '',
			),
		);
		// add a ruler line
		this.genStyle();
	}

	private clearLineHeightCache = () => (this.data.lineHeightCache = null);

	getLineHeight() {
		if (this.data.lineHeightCache != null) {
			return this.data.lineHeightCache.value;
		}
		let lineHeight = size2px(this.data.config.lineHeight, this.el, true) ?? DEFAULT_LINE_HEIGHT_PX;
		const cellPadding =
			size2px(this.data.config.cellPadding, this.el, true) ?? DEFAULT_CELL_PADDING_PX;
		lineHeight += cellPadding * 2 + this.data.config.borderBottom;
		this.data.lineHeightCache = {
			value: lineHeight,
			timeout: setTimeout(this.clearLineHeightCache, CACHE_TIME),
		};
		return lineHeight;
	}

	private getLineOffset(headHeight: number, lineHeight: number, line: number): number {
		if (this.data.lt[line] !== undefined) {
			// console.log('cached');
			return this.data.lt[line];
		} else {
			let lastLine = -1;
			for (let i = line - 1; i >= 0; i--) {
				if (this.data.lt[i] !== undefined) {
					lastLine = i;
					break;
				}
			}
			if (lastLine >= 0) {
				// console.log('compute from previouse line: ', lastLine, lastLine - line);
				const offset =
					this.data.lt[lastLine] +
					(line - lastLine) * lineHeight +
					this.data.lh.slice(lastLine, line).reduce((p, v) => p + v, 0);
				this.data.lt[line] = offset;
				return offset;
			} else {
				// console.log('compute from beginning!');
				return (
					headHeight + line * lineHeight + this.data.lh.slice(0, line).reduce((p, v) => p + v, 0)
				);
			}
		}
	}

	private computeScroll() {
		const lineHeight = this.getLineHeight();
		const totalLines = this.sortedData.length;
		const headHeight = this.headLine ? this.data.head.offsetHeight : 0;
		const scrollHeight =
			totalLines * lineHeight + headHeight + this.data.lh.reduce((p, v) => p + v, 0);
		const scrollTop = this.el.scrollTop;
		let beginLine = Math.max(Math.ceil((scrollTop - headHeight) / lineHeight), 0);
		let beginLineOffset = this.getLineOffset(headHeight, lineHeight, beginLine);
		while (beginLineOffset > scrollTop + headHeight) {
			beginLine--;
			beginLineOffset -=
				this.getLineHeight() + (this.data.lh[beginLine] ? this.data.lh[beginLine] : 0);
		}
		if (beginLine < 0) beginLine = 0;
		const clientHeight = this.el.clientHeight;
		const pageLines = Math.floor(clientHeight / lineHeight) + 2;
		this.data.anchor.style.top = `${scrollHeight - this.data.config.borderBottom}px`;
		return {
			scrollTop,
			beginLine,
			beginLineOffset,
			pageLines,
			lineHeight,
			scrollHeight,
		};
	}

	protected override renderBody() {
		if (!this.el.isConnected) return false;
		// console.log('render body:', this.scrollTop());
		const info = this.computeScroll();
		if (info.beginLine === this.data.first) return false;
		this.data.first = info.beginLine;
		const columns = this.getColumns();
		const lastCol = columns[columns.length - 1];
		const data = this.sortedData;
		this.el.querySelector(':scope>div.empty')?.remove();
		if (data.length > 0) {
			this.data.body.style.display = '';
			const lines = data
				.slice(info.beginLine, info.beginLine + info.pageLines)
				.map((row, rowIdx) => {
					const rowId = info.beginLine + rowIdx;
					const line = (
						<div
							draggable={this.rowDraggable}
							onDrag={(e) => {
								this.props.onRowDrag?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDragStart={(e) => {
								this.props.onRowDragStart?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDragEnd={(e) => {
								this.props.onRowDragEnd?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDragOver={(e) => {
								this.props.onRowDragOver?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDrop={(e) => {
								this.props.onRowDrop?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDragEnter={(e) => {
								this.props.onRowDragEnter?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							onDragLeave={(e) => {
								this.props.onRowDragLeave?.call(this as unknown as C, { rowIndex: rowId, row }, e);
							}}
							class={`r${rowId} line ${rowId === this.active ? 'active' : ''} ${this.isRowDisable(row) ? 'disable' : ''}`}
						/>
					) as HTMLDivElement;
					(line as any).lineNo = rowId;
					let sectionValue: unknown;

					if (this.data.section && (sectionValue = (row as any)[this.data.section.key])) {
						line.append(
							this.renderSection(document.createElement('div'), sectionValue, row, rowId, lastCol),
						);
					} else {
						line.append(
							...columns.map((col, colIdx) => {
								const c = this.renderCell(
									document.createElement('div'),
									row,
									info.beginLine + rowIdx,
									col,
									colIdx,
								);
								setCSS(c, this.data.css[colIdx] ?? {});
								return c;
							}),
						);
					}
					return line;
				});
			let offset = info.beginLineOffset;
			this.data.body.replaceChildren(...lines);
			this.data.body.style.top = offset + 'px';
			let totalExtra = 0;
			lines.forEach((line, idx) => {
				const id = info.beginLine + idx;
				this.data.lt[id] = offset;
				const lh = line.offsetHeight;
				const extra = lh - info.lineHeight;
				if (extra > 0) {
					if (!this.data.lh[id]) {
						totalExtra += extra;
						this.data.lh[id] = extra;
					}
				}
				offset += lh;
			});
			if (totalExtra > 0) {
				this.data.anchor.style.top = `${info.scrollHeight + totalExtra - this.data.config.borderBottom}px`;
			}
			this.data.lt.length = info.beginLine + info.pageLines;
			if (this.autoHeight) {
				this.el.style.height = `${this.data.anchor.offsetTop + 1}px`;
			}
		} else if (this.showEmpty) {
			this.data.body.style.display = 'none';
			this.el.append(
				<div class="empty">
					<Empty />
				</div>,
			);
			if (this.autoHeight) {
				this.el.style.height = 'auto';
			}
		} else if (this.autoHeight) {
			this.el.style.height = info.lineHeight + 'px';
		}
	}

	appendRows(rows: RowType[]): this {
		this[DATA].data = this[DATA].data.concat(rows);
		this[DATA].sorted = null;
		this.data.first = null;
		this.renderBody();
		return this;
	}

	startDrag(dragDetail: GridRowDragDetail, evt: DragEvent, dragOptions: DragOptions = {}): void {
		const row = this.el.querySelector(`div.r${dragDetail.rowIndex}`) as HTMLDivElement;
		const context: DragContext<this> = {
			grid: this,
			body: this.data.body,
			head: this.data.head,
			mousePos: { x: evt.clientX, y: evt.clientY },
			dragRow: dragDetail,
			over: false,
			dragOptions,
		};
		function onMouseMove(e: MouseEvent) {
			context.mousePos = { x: e.clientX, y: e.clientY };
		}
		row.addEventListener('dragend', () => {
			this.el.removeEventListener('dragover', onMouseMove);
			context.over = true;
		});
		this.el.addEventListener('dragover', onMouseMove);
		renderDrag(context);
	}

	startDragReorder(dragDetail: GridRowDragDetail, evt: DragEvent) {
		this.startDrag(dragDetail, evt, {
			onCompareRow(r1, r2) {
				return r1 === r2;
			},
			onDragEnd: (dragRow, targetRow, targetPos) => {
				if (!targetRow || !targetPos) return;
				// const dragParent = dragRow.row.parent ? this.getChildren(dragRow.row.parent.row) : this.rows;
				// const targetParent = targetRow.row.parent ? this.getChildren(targetRow.row.parent.row) : this.rows;
				// if (!dragParent || !targetParent) return;
				const dragOffset = this.rows.indexOf(dragRow.row);
				this.rows.splice(dragOffset, 1);
				const targetOffset = this.rows.indexOf(targetRow.row);
				if (targetPos === 'before') {
					this.rows.splice(targetOffset, 0, dragRow.row);
				} else if (targetPos === 'after') {
					this.rows.splice(targetOffset + 1, 0, dragRow.row);
				}
				this.refresh('data');
			},
		});
	}

	get autoHeight(): boolean {
		return this.el.hasAttribute('auto-height');
	}
	setAutoHeight(value: boolean): this {
		if (value) {
			this.el.setAttribute('auto-height', '');
		} else {
			this.el.removeAttribute('auto-height');
		}
		return this;
	}
}
